Add labels to the settings web interface This new form allows project owners to configure the behavior of their labels: what function should be used to allow/block submission, if the change owner can self-approve his change, and when the votes should be copied. Change-Id: I180de2b6c3c5decf8192e84d960c37a861d9240a
diff --git a/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config.html b/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config.html new file mode 100644 index 0000000..79bc203 --- /dev/null +++ b/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config.html
@@ -0,0 +1,219 @@ +<!-- +@license +Copyright (C) 2018 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> +<!-- TODO(maximeg) Import the web components when this issue is solved: + https://bugs.chromium.org/p/gerrit/issues/detail?id=8096 --> + +<dom-module id="gr-simple-submit-rules-label-config"> + <template> + <style include="shared-styles"></style> + <style include="gr-form-styles"></style> + <style> + :host { + border: 1px solid var(--border-color); + display: block; + margin-bottom: 1em; + padding: 1em 1em; + } + + fieldset { + border: 1px solid var(--border-color); + } + </style> + + <main class="gr-form-styles"> + <h3 id="options">Label [[labelName]]</h3> + + <fieldset id="simple-submit-rules"> + + <section> + <span class="title">Should a vote with the maximum value be required?</span> + <span class="value"> + <input id="maxVoteRequired" + type="checkbox" + checked="{{_maxVoteRequired::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + + <section> + <span class="title">Should votes with the lowest value block submission?</span> + <span class="value"> + <input id="negativeBlocks" + type="checkbox" + checked="{{_negativeBlocks::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + + <section> + <span class="title">(Expert users) Function name</span> + <span class="value"> + <gr-select id="functionName" + bind-value="{{_labelConfig.function}}"> + <select disabled$="[[readOnly]]"> + <option value="MaxNoBlock">MaxNoBlock</option> + <option value="MaxWithBlock">MaxWithBlock</option> + <option value="AnyWithBlock">AnyWithBlock</option> + <option value="NoBlock">NoBlock</option> + <option value="NoOp">NoOp</option> + </select> + </gr-select> + </span> + </section> + + <section> + <span class="title">Allow approval by the change owner</span> + <span class="value"> + <input id="allowUnresolvedComments" + type="checkbox" + checked="{{_labelConfig.ignore_self_approval::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + </fieldset> + + <fieldset> + <section> + <span class="title"> + When a new patchset is uploaded, Gerrit should copy votes ... + </span> + </section> + + <!-- copyMinScore --> + <section> + <span class="title"> + <gr-tooltip-content class="draftTooltip" + has-tooltip + title="Should votes of the minimal value be kept?" + max-width="20em" + show-icon> + with minimal value + </gr-tooltip-content> + </span> + <span class="value"> + <input id="copyMinScore" + type="checkbox" + checked="{{_copyScores.copyMinScore::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + + <!-- copyMaxScore --> + <section> + <span class="title"> + <gr-tooltip-content class="draftTooltip" + has-tooltip + title="Should votes of the maximal value be kept?" + max-width="20em" + show-icon> + with maximal value + </gr-tooltip-content> + </span> + <span class="value"> + <input id="copyMaxScore" + type="checkbox" + checked="{{_copyScores.copyMaxScore::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + + <!-- copyAllScoresOnTrivialRebase --> + <section> + <span class="title"> + <gr-tooltip-content class="draftTooltip" + has-tooltip + title="Should votes be kept when a trivial rebase + is done (same commit message and content, different parent)?" + max-width="20em" + show-icon> + on trivial rebase + </gr-tooltip-content> + </span> + <span class="value"> + <input id="copyAllScoresOnTrivialRebase" + type="checkbox" + checked="{{_copyScores.copyAllScoresOnTrivialRebase::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + + <!-- copyAllScoresIfNoCodeChange --> + <section> + <span class="title"> + <gr-tooltip-content class="draftTooltip" + has-tooltip + title="Should votes be kept when the commit message is modified? + Changing the parent or changing files invalidates this." + max-width="20em" + show-icon> + when only the commit message is modified + </gr-tooltip-content> + </span> + <span class="value"> + <input id="copyAllScoresIfNoCodeChange" + type="checkbox" + checked="{{_copyScores.copyAllScoresIfNoCodeChange::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + + <!-- copyAllScoresIfNoChange --> + <section> + <span class="title"> + <gr-tooltip-content class="draftTooltip" + has-tooltip + title="Should votes be kept when the commit metadata (author, commit + date) are modified? Changing anything else from the commit + (message, content, parent) invalidates this." + max-width="20em" + show-icon> + when only commit metatada are modified + </gr-tooltip-content> + </span> + <span class="value"> + <input id="copyAllScoresIfNoChange" + type="checkbox" + checked="{{_copyScores.copyAllScoresIfNoChange::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + + <!-- copyAllScoresOnMergeFirstParentUpdate --> + <section> + <span class="title"> + <gr-tooltip-content class="draftTooltip" + has-tooltip + title="Only applies to Merge commits. Should votes be kept when the + destination commit of a merge commit is changed?" + max-width="20em" + show-icon> + on rebased merge commits + </gr-tooltip-content> + </span> + <span class="value"> + <input id="copyAllScoresOnMergeFirstParentUpdate" + type="checkbox" + checked="{{_copyScores.copyAllScoresOnMergeFirstParentUpdate::change}}" + disabled$="[[readOnly]]"> + </span> + </section> + + </fieldset> + </main> + </template> + <script src="./gr-simple-submit-rules-label-config.js"></script> +</dom-module> diff --git a/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config.js b/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config.js new file mode 100644 index 0000000..7e11c39 --- /dev/null +++ b/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config.js
@@ -0,0 +1,133 @@ +/** + * @license + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function () { + 'use strict'; + + const COPY_SCORES = [ + 'copyMinScore', + 'copyMaxScore', + 'copyAllScoresOnTrivialRebase', + 'copyAllScoresIfNoCodeChange', + 'copyAllScoresIfNoChange', + 'copyAllScoresOnMergeFirstParentUpdate' + ]; + + Polymer({ + is: 'gr-simple-submit-rules-label-config', + + properties: { + labelName: String, + /** @type {?} */ + repoConfig: { + type: Object, + notify: true, + }, + readOnly: Boolean, + // The two "_updating" booleans are there to prevent an infinite loop: + // when the user changes a value, we update another value and this + // update in turn triggers the function again. + _updatingFunction: { + type: Boolean, + value: false + }, + _updatingCopyScores: { + type: Boolean, + value: false + }, + _labelConfig: { + type: Object, + computed: '_computeLabelConfig(repoConfig.labels, labelName)' + }, + _copyScores: { + type: Object, + value: {}, + }, + _negativeBlocks: Boolean, + _maxVoteRequired: Boolean, + }, + + observers: [ + '_observeFunctionChange(_labelConfig.function)', + '_observeFunctionDescriptorChange(_negativeBlocks, _maxVoteRequired)', + '_observeCopyScoresChange(_labelConfig.copy_scores)', + '_observeCopyScoresChangeInUi(_copyScores.*)', + ], + + _observeFunctionDescriptorChange(negativeBlocks, maxVoteRequired) { + if (this._labelConfig === undefined) { return; } + if (this._updatingFunction) { return; } + this._updatingFunction = true; + let fName = ''; + + if (maxVoteRequired) { + fName = negativeBlocks ? 'MaxWithBlock' : 'MaxNoBlock'; + } else { + fName = negativeBlocks ? 'AnyWithBlock' : 'NoBlock'; + } + this.set('_labelConfig.function', fName); + + this._updatingFunction = false; + }, + + _observeFunctionChange(_function) { + if (this._updatingFunction) { return; } + this._updatingFunction = true; + + this._negativeBlocks = _function.indexOf('WithBlock') !== -1; + this._maxVoteRequired = _function.indexOf('Max') !== -1; + + this._updatingFunction = false; + }, + + _computeLabelConfig(labels, labelName) { + this.linkPaths(['repoConfig.labels', labelName], '_labelConfig'); + this.linkPaths('_labelConfig', ['repoConfig.labels', labelName]); + return labels[this.labelName] || {}; + }, + + _observeCopyScoresChange() { + if (this._updatingCopyScores) { return; } + this._updatingCopyScores = true; + + for (let key of COPY_SCORES) { + this.set(['_copyScores', key], false); + } + for (let value of this._labelConfig.copy_scores) { + this.set(['_copyScores', value], true); + } + + this._updatingCopyScores = false; + }, + + _observeCopyScoresChangeInUi() { + if (this._updatingCopyScores) { return; } + this._updatingCopyScores = true; + + + let newCopyScores = []; + for (let key in this._copyScores) { + if (this._copyScores[key]) { + newCopyScores.push(key); + } + } + this.set('_labelConfig.copy_scores', newCopyScores); + + this._updatingCopyScores = false; + }, + }); +})(); diff --git a/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config_test.html b/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config_test.html new file mode 100644 index 0000000..97408cf --- /dev/null +++ b/gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config_test.html
@@ -0,0 +1,270 @@ +<!DOCTYPE html> +<!-- +@license +Copyright (C) 2016 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> +<meta name="viewport" + content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes"> +<title>gr-simple-submit-rules-label-config</title> + +<script src="../../../polygerrit-ui/app/bower_components/webcomponentsjs/webcomponents-lite.min.js"></script> +<script src="../../../polygerrit-ui/app/bower_components/web-component-tester/browser.js"></script> +<link rel="import" + href="../../../polygerrit-ui/app/test/common-test-setup.html" /> +<!-- TODO(maximeg) find if there is a better way to do this, like .. not in tests --> +<link rel="import" + href="../../../polygerrit-ui/app/elements/shared/gr-select/gr-select.html" /> +<link rel="import" + href="../../../polygerrit-ui/app/styles/shared-styles.html" /> +<link rel="import" + href="../../../polygerrit-ui/app/styles/gr-form-styles.html" /> + +<link rel="import" + href="gr-simple-submit-rules-label-config.html"> + +<script>void (0);</script> + +<test-fixture id="basic"> + <template> + <gr-simple-submit-rules-label-config label-name="Verified" + repo-config='{"labels": {"Verified": {"function": "MaxNoBlock", "copy_scores": []}}}' + readOnly="false"> + </gr-simple-submit-rules-label-config> + </template> +</test-fixture> + +<script> + const COPY_SCORES = [ + 'copyMinScore', + 'copyMaxScore', + 'copyAllScoresOnTrivialRebase', + 'copyAllScoresIfNoCodeChange', + 'copyAllScoresIfNoChange', + 'copyAllScoresOnMergeFirstParentUpdate' + ]; + + suite('gr-simple-submit-rules-label-config tests', () => { + let element; + let sandbox; + + setup(() => { + sandbox = sinon.sandbox.create(); + + element = fixture('basic'); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('section title is correct', () => { + assert.equal(element.$$('#options').innerText.indexOf('Label Verified'), 0); + }); + + // The following tests check that changing the function name (from the REST API) + // has an impact on the displayed settings + test('function "MaxWithBlock" is properly mapped to UI', done => { + element.set('_labelConfig.function', 'MaxWithBlock'); + + flush(function () { + assert.ok(element.$$('#negativeBlocks').checked); + assert.ok(element.$$('#maxVoteRequired').checked); + assert.equal(element.$$('#functionName select').value, 'MaxWithBlock'); + done(); + }); + }); + + test('function "MaxNoBlock" is properly mapped to UI', done => { + element.set('_labelConfig.function', 'MaxNoBlock'); + + flush(function () { + assert.equal(element.$$('#negativeBlocks').checked, false); + assert.ok(element.$$('#maxVoteRequired').checked); + assert.equal(element.$$('#functionName select').value, 'MaxNoBlock'); + done(); + }); + }); + + test('function "NoBlock" is properly mapped to UI', done => { + element.set('_labelConfig.function', 'NoBlock'); + + flush(function () { + assert.equal(element.$$('#negativeBlocks').checked, false); + assert.equal(element.$$('#maxVoteRequired').checked, false); + assert.equal(element.$$('#functionName select').value, 'NoBlock'); + done(); + }); + }); + + test('function "AnyWithBlock" is properly mapped to UI', done => { + element.set('_labelConfig.function', 'AnyWithBlock'); + + flush(function () { + assert.ok(element.$$('#negativeBlocks').checked); + assert.equal(element.$$('#maxVoteRequired').checked, false); + assert.equal(element.$$('#functionName select').value, 'AnyWithBlock'); + done(); + }); + }); + + // The following tests check that changing the function from the UI has a + // visible impact on the easy checkboxes options, while keeping the function + // name intact. + test('picking function "MaxWithBlock" correctly updates the UI', done => { + element.$$('#functionName select').value = 'MaxWithBlock'; + element.$$('#functionName').dispatchEvent(new Event('change')); + + flush(function () { + assert.ok(element.$$('#negativeBlocks').checked); + assert.ok(element.$$('#maxVoteRequired').checked); + assert.equal(element.$$('#functionName select').value, 'MaxWithBlock'); + done(); + }); + }); + + test('picking function "NoBlock" correctly updates the UI', done => { + element.$$('#functionName select').value = 'NoBlock'; + element.$$('#functionName').dispatchEvent(new Event('change')); + + flush(function () { + assert.equal(element.$$('#negativeBlocks').checked, false); + assert.equal(element.$$('#maxVoteRequired').checked, false); + assert.equal(element.$$('#functionName select').value, 'NoBlock'); + done(); + }); + }); + + test('picking function "AnyWithBlock" correctly updates the UI', done => { + element.$$('#functionName select').value = 'AnyWithBlock'; + element.$$('#functionName').dispatchEvent(new Event('change')); + + flush(function () { + assert.ok(element.$$('#negativeBlocks').checked); + assert.equal(element.$$('#maxVoteRequired').checked, false); + assert.equal(element.$$('#functionName select').value, 'AnyWithBlock'); + done(); + }); + }); + + test('picking function "MaxNoBlock" correctly updates the UI', done => { + element.$$('#functionName select').value = 'MaxNoBlock'; + element.$$('#functionName').dispatchEvent(new Event('change')); + + flush(function () { + assert.equal(element.$$('#negativeBlocks').checked, false); + assert.ok(element.$$('#maxVoteRequired').checked); + assert.equal(element.$$('#functionName select').value, 'MaxNoBlock'); + done(); + }); + }); + + // The following tests check that the easy way to define the function + // ("negative blocks?" and "max vote required?") maps to the right function, + // without impacting the user choices. + test('function "MaxWithBlock" is properly suggested from UI', done => { + element.$$('#negativeBlocks').checked = true; + element.$$('#negativeBlocks').dispatchEvent(new Event('change')); + + element.$$('#maxVoteRequired').checked = 'true'; + element.$$('#maxVoteRequired').dispatchEvent(new Event('change')); + + flush(function () { + assert.ok(element.$$('#negativeBlocks').checked); + assert.ok(element.$$('#maxVoteRequired').checked); + assert.equal(element.$$('#functionName select').value, 'MaxWithBlock'); + done(); + }); + }); + + test('function "NoBlock" is properly suggested from UI', done => { + element.$$('#negativeBlocks').checked = false; + element.$$('#negativeBlocks').dispatchEvent(new Event('change')); + + element.$$('#maxVoteRequired').checked = false; + element.$$('#maxVoteRequired').dispatchEvent(new Event('change')); + + flush(function () { + assert.equal(element.$$('#negativeBlocks').checked, false); + assert.equal(element.$$('#maxVoteRequired').checked, false); + assert.equal(element.$$('#functionName select').value, 'NoBlock'); + done(); + }); + }); + + test('function "AnyWithBlock" is properly suggested from UI', done => { + element.$$('#negativeBlocks').checked = true; + element.$$('#negativeBlocks').dispatchEvent(new Event('change')); + + element.$$('#maxVoteRequired').checked = false; + element.$$('#maxVoteRequired').dispatchEvent(new Event('change')); + + flush(function () { + assert.ok(element.$$('#negativeBlocks').checked); + assert.equal(element.$$('#maxVoteRequired').checked, false); + assert.equal(element.$$('#functionName select').value, 'AnyWithBlock'); + done(); + }); + }); + + test('function "MaxNoBlock" is properly suggested from UI', done => { + element.$$('#negativeBlocks').checked = false; + + element.$$('#maxVoteRequired').checked = 'true'; + element.$$('#maxVoteRequired').dispatchEvent(new Event('change')); + + flush(function () { + assert.equal(element.$$('#negativeBlocks').checked, false); + assert.ok(element.$$('#maxVoteRequired').checked); + assert.equal(element.$$('#functionName select').value, 'MaxNoBlock'); + done(); + }); + }); + + // The following tests check that "copy scores" are correctly mapped *to* and + // *from* the UI, for the two possible boolean values. + for (let copyScoreName of COPY_SCORES) { + let elName = '#' + copyScoreName; + + test('copyScore.' + copyScoreName + ' [false] is reflected *in* the UI', () => { + assert.equal(element.$$(elName).checked, false); + }); + + test('copyScore.' + copyScoreName + ' [true] is reflected in the UI', done => { + element.set('_labelConfig.copy_scores', [copyScoreName]); + + flush(function () { + assert.ok(element.$$(elName).checked); + done(); + }); + }); + } + + + for (let copyScoreName of COPY_SCORES) { + let elName = '#' + copyScoreName; + + test('copyScore.' + copyScoreName + ' is reflected *from* the UI', done => { + element.$$(elName).checked = 'true'; + element.$$(elName).dispatchEvent(new Event('change')); + + flush(function () { + assert.deepEqual(element.repoConfig.labels.Verified.copy_scores, [copyScoreName]); + done(); + }); + }); + } + + }); +</script> diff --git a/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config.html b/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config.html index 44e3db1..23cc805 100644 --- a/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config.html +++ b/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config.html
@@ -15,6 +15,11 @@ limitations under the License. --> +<link rel="import" + href="../gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config.html"> +<!-- TODO(maximeg) Import the web components when this issue is solved: + https://bugs.chromium.org/p/gerrit/issues/detail?id=8096 --> + <dom-module id="gr-simple-submit-rules-repo-config"> <template> <style include="shared-styles"></style> @@ -36,6 +41,18 @@ </gr-select> </span> </section> + + <template is="dom-repeat" + id="allLabels" + items="[[_labels]]" + initial-count="5" + target-framerate="60"> + <gr-simple-submit-rules-label-config mutable-data + label-name="[[item]]" + repo-config="{{_repoConfig}}" + read-only="[[_readOnly]]"> + </gr-simple-submit-rules-label-config> + </template> </fieldset> <gr-button on-tap="_handleSaveRepoConfig" diff --git a/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config.js b/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config.js index c9daafe..f745939 100644 --- a/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config.js +++ b/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config.js
@@ -33,7 +33,12 @@ value: true, }, _restApi: Object, - }, + _labels: { + type: Array, + value() { return []; }, + computed: '_computeLabelNames(_repoConfig.*)', + }, + }, observers: [ '_handleConfigChanged(_repoConfig.*)', @@ -75,7 +80,7 @@ promises.push(this._pluginRestApi().get(this._endpointUrl()) .then(config => { if (!config) { return; } - this._repoConfig = config; + this.set('_repoConfig', config); this._loading = false; })); @@ -120,5 +125,12 @@ return this._pluginRestApi().get('/access/?project=' + encodeURIComponent(repoName)); }, + _computeLabelNames() { + if (this._repoConfig && this._repoConfig.labels) { + return Object.keys(this._repoConfig.labels); + } + return []; + }, + }); })(); diff --git a/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config_test.html b/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config_test.html index 4e35e86..f71b6f7 100644 --- a/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config_test.html +++ b/gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config_test.html
@@ -60,6 +60,7 @@ comments: { block_if_unresolved_comments: false, }, + labels: {}, }); }, getLoggedIn() { return Promise.resolve(true); }, @@ -98,6 +99,42 @@ assert.equal(unresolvedCommentsEl.disabled, true); }); + test('adds a label element', done => { + element.set(['_repoConfig', 'labels', 'Verified'], { + function: 'MaxNoBlock', + copy_scores: [] + }); + flush(function () { + let labelItems = element.querySelectorAll('gr-simple-submit-rules-label-config'); + assert.ok(labelItems); + assert.equal(labelItems.length, 1); + + let labelEl = labelItems[0]; + assert.ok(labelEl); + assert.equal(labelEl.labelName, 'Verified'); + assert.equal(labelEl.readOnly, false); + done(); + }); + }); + + test('adds two labels elements', done => { + element.set(['_repoConfig', 'labels', 'Verified'], { + function: 'MaxNoBlock', + copy_scores: [] + }); + + element.set(['_repoConfig', 'labels', 'Code-Review'], { + function: 'MaxNoBlock', + copy_scores: [] + }); + + flush(function () { + let labelItems = element.querySelectorAll('gr-simple-submit-rules-label-config'); + assert.equal(labelItems.length, 2); + done(); + }); + }); + test('unresolved comment uses the repoConfig value (false)', done => { element.set('_repoConfig.comments.block_if_unresolved_comments', false); diff --git a/tests.html b/tests.html index e4dbfc4..32c3317 100644 --- a/tests.html +++ b/tests.html
@@ -11,6 +11,7 @@ <script> WCT.loadSuites([ 'gr-simple-submit-rules-repo-config/gr-simple-submit-rules-repo-config_test.html', + 'gr-simple-submit-rules-label-config/gr-simple-submit-rules-label-config_test.html' ]); </script> </body>